// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

import 'dart:async';
import 'dart:convert';

import 'package:analysis_server/lsp_protocol/protocol.dart';
import 'package:analysis_server/protocol/protocol_constants.dart';
import 'package:analysis_server/src/analytics/active_request_data.dart';
import 'package:analysis_server/src/analytics/context_structure.dart';
import 'package:analysis_server/src/analytics/notification_data.dart';
import 'package:analysis_server/src/analytics/plugin_data.dart';
import 'package:analysis_server/src/analytics/request_data.dart';
import 'package:analysis_server/src/analytics/session_data.dart';
import 'package:analysis_server/src/lsp/lsp_analysis_server.dart';
import 'package:analysis_server/src/plugin/plugin_manager.dart';
import 'package:analysis_server/src/protocol_server.dart';
import 'package:analysis_server/src/status/pages.dart';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/src/dart/analysis/driver_based_analysis_context.dart';
import 'package:collection/collection.dart';
import 'package:memory_usage/memory_usage.dart';
import 'package:meta/meta.dart';
import 'package:unified_analytics/unified_analytics.dart';

/// An interface for managing and reporting analytics.
///
/// Individual methods can either send an analytics event immediately or can
/// collect and even consolidate information to be reported later. Clients are
/// required to invoke the [shutdown] method before the server shuts down in
/// order to send any cached data.
class AnalyticsManager {
  /// A flag set during development to allow experimental data to be sent to a
  /// development-time analytics account.
  static const bool sendExperimentalData = false;

  static const addedKey = 'added';

  static const removedKey = 'removed';

  static const commandEnumKey = 'command';

  static const openWorkspacePathsKey = 'openWorkspacePaths';

  static const refactoringKindEnumKey = EDIT_REQUEST_GET_REFACTORING_KIND;

  static const includedKey = ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS_INCLUDED;

  static const excludedKey = ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS_EXCLUDED;

  static const filesKey = ANALYSIS_REQUEST_SET_PRIORITY_FILES_FILES;

  /// The object used to send analytics.
  final Analytics analytics;

  /// Data about the current session, or `null` if the [startUp] method has not
  /// been invoked.
  SessionData? _sessionData;

  final PluginData _pluginData = PluginData();

  /// The data about analysis, or `null` if no analysis has been performed.
  ContextStructure? _contextStructure;

  /// A map from the id of a request to data about the request.
  final Map<String, ActiveRequestData> _activeRequests = {};

  /// A map from the name of a request to data about all such requests that have
  /// been responded to.
  final Map<String, RequestData> _completedRequests = {};

  /// A map from the name of a notification to data about all such notifications
  /// that have been handled.
  final Map<String, NotificationData> _completedNotifications = {};

  /// A map from the name of a lint to the number of options files in which the lint
  /// was enabled.
  final Map<String, int> _lintUsageCounts = {};

  /// A map from the name of a diagnostic to a map whose values are the number
  /// of times that the severity of the diagnostic was changed to the severity
  /// represented by the key.
  final Map<String, Map<String, int>> _severityAdjustments = {};

  /// A periodic timer used to send analytics data. This timer should be
  /// cancelled at shutdown.
  Timer? periodicTimer;

  /// Initialize a newly created analytics manager to report to the [analytics]
  /// service.
  AnalyticsManager(this.analytics) {
    if (analytics is! NoOpAnalytics) {
      periodicTimer = Timer.periodic(Duration(minutes: 30), (_) {
        _sendPeriodicData();
      });
    }
  }

  /// Record information about the number of files and the number of lines of
  /// code in those files, for both immediate files, transitive files, and the
  /// number of unique transitive files.
  void analysisComplete({
    required int numberOfContexts,
    required int immediateFileCount,
    required int immediateFileLineCount,
    required int transitiveFileCount,
    required int transitiveFileLineCount,
    required int transitiveFileUniqueCount,
    required int transitiveFileUniqueLineCount,
  }) {
    // This is currently keeping the first report of completed analysis, but we
    // might want to consider alternatives, such as keeping the "largest"
    // analysis or keeping all of the data and sending back percentile.
    _contextStructure ??= ContextStructure(
      numberOfContexts: numberOfContexts,
      immediateFileCount: immediateFileCount,
      immediateFileLineCount: immediateFileLineCount,
      transitiveFileCount: transitiveFileCount,
      transitiveFileLineCount: transitiveFileLineCount,
      transitiveFileUniqueCount: transitiveFileUniqueCount,
      transitiveFileUniqueLineCount: transitiveFileUniqueLineCount,
    );
  }

  /// Record that the set of plugins known to the [pluginManager] has changed.
  void changedPlugins(PluginManager pluginManager) {
    _pluginData.recordPlugins(pluginManager);
  }

  /// Record the number of [added] folders and [removed] folders.
  void changedWorkspaceFolders({
    required List<String> added,
    required List<String> removed,
  }) {
    var requestData = getRequestData(
      Method.workspace_didChangeWorkspaceFolders.toString(),
    );
    requestData.addValue(addedKey, added.length);
    requestData.addValue(removedKey, removed.length);
  }

  /// Record that the [contexts] have been created.
  void createdAnalysisContexts(List<AnalysisContext> contexts) {
    for (var context in contexts) {
      var allOptions =
          (context as DriverBasedAnalysisContext).allAnalysisOptions;
      for (var analysisOptions in allOptions) {
        for (var rule in analysisOptions.lintRules) {
          var name = rule.name;
          _lintUsageCounts[name] = (_lintUsageCounts[name] ?? 0) + 1;
        }

        for (var processor in analysisOptions.errorProcessors) {
          var severity = processor.severity?.name ?? 'ignore';
          var severityCounts = _severityAdjustments.putIfAbsent(
            processor.code,
            () => {},
          );
          severityCounts[severity] = (severityCounts[severity] ?? 0) + 1;
        }
      }
    }
  }

  /// Record that the given [command] was executed.
  void executedCommand(String command) {
    var requestData = getRequestData(
      Method.workspace_executeCommand.toString(),
    );
    requestData.addEnumValue(commandEnumKey, command);
  }

  /// Return the request data for requests that have the given [method].
  @visibleForTesting
  RequestData getRequestData(String method) {
    return _completedRequests.putIfAbsent(method, () => RequestData(method));
  }

  /// Record that the given [notification] was received and has been handled.
  void handledNotificationMessage({
    required NotificationMessage notification,
    required DateTime startTime,
    required DateTime endTime,
  }) {
    var method = notification.method.toString();
    var requestTime = notification.clientRequestTime;
    var start = startTime.millisecondsSinceEpoch;
    var end = endTime.millisecondsSinceEpoch;
    var data = _completedNotifications.putIfAbsent(
      method,
      () => NotificationData(method),
    );
    if (requestTime != null) {
      data.latencyTimes.addValue(start - requestTime);
    }
    data.handlingTimes.addValue(end - start);
  }

  /// Record the parameters passed on initialize.
  void initialize(InitializeParams params) {
    var options = LspInitializationOptions(params.initializationOptions);
    var paramNames = <String>[
      if (options.closingLabels) 'closingLabels',
      if (options.completionBudgetMilliseconds != null)
        'completionBudgetMilliseconds',
      if (options.flutterOutline) 'flutterOutline',
      if (options.onlyAnalyzeProjectsWithOpenFiles)
        'onlyAnalyzeProjectsWithOpenFiles',
      if (options.outline) 'outline',
      if (options.suggestFromUnimportedLibraries)
        'suggestFromUnimportedLibraries',
    ];
    _sessionData?.initializeParams = paramNames.join(',');
  }

  /// Record the number of [openWorkspacePaths].
  void initialized({required List<String> openWorkspacePaths}) {
    var requestData = getRequestData(Method.initialized.toString());
    requestData.addValue(openWorkspacePathsKey, openWorkspacePaths.length);
  }

  bool needsAnslysisCompleteCall() => _contextStructure == null;

  Future<void> sendMemoryUsage(MemoryUsageEvent event) async {
    var delta = event.delta;
    var seconds = event.period?.inSeconds;

    assert((event.delta == null) == (event.period == null));

    if (delta == null || seconds == null) {
      analytics.send(Event.memoryInfo(rss: event.rss));
      return;
    }

    if (seconds == 0) seconds = 1;

    analytics.send(
      Event.memoryInfo(
        rss: event.rss,
        periodSec: seconds,
        mbPerSec: delta / seconds,
      ),
    );
  }

  /// Record that the given [response] was sent to the client.
  void sentResponse({required Response response}) {
    var sendTime = DateTime.now();
    _recordResponseData(response.id, sendTime);
  }

  /// Record that the given [response] was sent to the client.
  void sentResponseMessage({required ResponseMessage response}) {
    var sendTime = DateTime.now();
    var id = response.id?.asLspIdString;
    if (id == null) {
      return;
    }
    _recordResponseData(id, sendTime);
  }

  /// The server is shutting down. Report any accumulated analytics data.
  Future<void> shutdown() async {
    var sessionData = _sessionData;
    if (sessionData == null) {
      return;
    }
    await _sendSessionData(sessionData);
    await _sendPeriodicData();
    await _sendAnalysisData();

    periodicTimer?.cancel();
    periodicTimer = null;
    await analytics.close();
  }

  /// Record data from the given [params].
  void startedGetRefactoring(EditGetRefactoringParams params) {
    var requestData = getRequestData(EDIT_REQUEST_GET_REFACTORING);
    requestData.addEnumValue(refactoringKindEnumKey, params.kind.name);
  }

  /// Record that the server started working on the give [request] at the given
  /// [startTime].
  void startedRequest({required Request request, required DateTime startTime}) {
    var method = request.method;
    _activeRequests[request.id] = ActiveRequestData(
      method,
      request.clientRequestTime,
      startTime,
    );
  }

  /// Record that the server started working on the give [request] at the given
  /// [startTime].
  void startedRequestMessage({
    required RequestMessage request,
    required DateTime startTime,
  }) {
    _activeRequests[request.id.asLspIdString] = ActiveRequestData(
      request.method.toString(),
      request.clientRequestTime,
      startTime,
    );
  }

  /// Record data from the given [params].
  void startedSetAnalysisRoots(AnalysisSetAnalysisRootsParams params) {
    var requestData = getRequestData(ANALYSIS_REQUEST_SET_ANALYSIS_ROOTS);
    requestData.addValue(includedKey, params.included.length);
    requestData.addValue(excludedKey, params.excluded.length);
  }

  /// Record data from the given [params].
  void startedSetPriorityFiles(AnalysisSetPriorityFilesParams params) {
    var requestData = getRequestData(ANALYSIS_REQUEST_SET_PRIORITY_FILES);
    requestData.addValue(
      ANALYSIS_REQUEST_SET_PRIORITY_FILES_FILES,
      params.files.length,
    );
  }

  /// Record that the server was started at the given [time], that it was passed
  /// the given command-line [arguments], that it was started by the client with
  /// the given [clientId] and [clientVersion].
  void startUp({
    required DateTime time,
    required List<String> arguments,
    required String clientId,
    required String? clientVersion,
  }) {
    _sessionData = SessionData(
      startTime: time,
      commandLineArguments: arguments.join(','),
      clientId: clientId,
      clientVersion: clientVersion ?? '',
    );
  }

  /// Return an HTML representation of the data that has been recorded.
  String? toHtml(StringBuffer buffer) {
    var sessionData = _sessionData;
    if (sessionData == null) {
      return null;
    }

    void h3(String title) {
      buffer.writeln('<h3>${escape(title)}</h3>');
    }

    void h4(String title) {
      buffer.writeln('<h4>${escape(title)}</h4>');
    }

    void h5(String title) {
      buffer.writeln('<h5>${escape(title)}</h5>');
    }

    void li(String item) {
      buffer.writeln('<li>${escape(item)}</li>');
    }

    List<MapEntry<String, V>> sorted<V>(
      Iterable<MapEntry<String, V>> entries,
    ) => entries.sortedBy((entry) => entry.key);

    buffer.writeln('<hr>');

    var endTime = DateTime.now().millisecondsSinceEpoch;
    var duration = endTime - sessionData.startTime.millisecondsSinceEpoch;
    h3('Session data');
    buffer.writeln('<ul>');
    li('clientId: ${sessionData.clientId}');
    li('clientVersion: ${sessionData.clientVersion}');
    li('duration: ${duration.toString()}');
    li('flags: ${sessionData.commandLineArguments}');
    li('parameters: ${sessionData.initializeParams}');
    li('plugins: ${_pluginData.usageCountData}');
    buffer.writeln('</ul>');

    if (_completedRequests.isNotEmpty) {
      h3('Server response times');
      var entries = sorted(_completedRequests.entries);
      for (var entry in entries) {
        var data = entry.value;
        h4(data.method);
        buffer.writeln('<ul>');
        li('latency: ${data.latencyTimes.toAnalyticsString()}');
        li('duration: ${data.responseTimes.toAnalyticsString()}');
        for (var field in data.additionalPercentiles.entries) {
          li('${field.key}: ${field.value.toAnalyticsString()}');
        }
        for (var field in data.additionalEnumCounts.entries) {
          li('${field.key}: ${json.encode(field.value)}');
        }
        buffer.writeln('</ul>');
      }
    }

    var responseTimes = PluginManager.pluginResponseTimes;
    if (responseTimes.isNotEmpty) {
      h3('Plugin response times');
      for (var pluginEntry in responseTimes.entries) {
        h4(pluginEntry.key.safePluginId);
        var entries = sorted(pluginEntry.value.entries);
        for (var responseEntry in entries) {
          h5(responseEntry.key);
          buffer.writeln('<ul>');
          li('duration: ${responseEntry.value.toAnalyticsString()}');
          buffer.writeln('</ul>');
        }
      }
    }

    if (_completedNotifications.isNotEmpty) {
      h3('Notification handling times');
      buffer.writeln('<ul>');
      var entries = sorted(_completedNotifications.entries);
      for (var entry in entries) {
        var data = entry.value;
        li('latency: ${data.latencyTimes.toAnalyticsString()}');
        li('method: ${data.method}');
        li('duration: ${data.handlingTimes.toAnalyticsString()}');
      }
      buffer.writeln('</ul>');
    }

    if (_lintUsageCounts.isNotEmpty) {
      h3('Lint usage counts');
      buffer.writeln('<ul>');
      li('usageCounts: ${json.encode(_lintUsageCounts)}');
      buffer.writeln('</ul>');
    }

    if (_severityAdjustments.isNotEmpty) {
      h3('Severity adjustments');
      buffer.writeln('<ul>');
      li('adjustmentCounts: ${json.encode(_severityAdjustments)}');
      buffer.writeln('</ul>');
    }

    var analysisData = _contextStructure;
    if (analysisData != null) {
      h3('Analysis data');
      buffer.writeln('<ul>');
      li('numberOfContexts: ${json.encode(analysisData.numberOfContexts)}');
      li('immediateFileCount: ${json.encode(analysisData.immediateFileCount)}');
      li(
        'immediateFileLineCount: ${json.encode(analysisData.immediateFileLineCount)}',
      );
      li(
        'transitiveFileCount: ${json.encode(analysisData.transitiveFileCount)}',
      );
      li(
        'transitiveFileLineCount: ${json.encode(analysisData.transitiveFileLineCount)}',
      );
      li(
        'transitiveFileUniqueCount: ${json.encode(analysisData.transitiveFileUniqueCount)}',
      );
      li(
        'transitiveFileUniqueLineCount: ${json.encode(analysisData.transitiveFileUniqueLineCount)}',
      );
      buffer.writeln('</ul>');
    }

    return buffer.toString();
  }

  /// Record that the request with the given [id] was responded to at the given
  /// [sendTime].
  void _recordResponseData(String id, DateTime sendTime) {
    var data = _activeRequests.remove(id);
    if (data == null) {
      return;
    }

    var method = data.method;
    var clientRequestTime = data.clientRequestTime;
    var startTime = data.startTime.millisecondsSinceEpoch;

    var requestData = getRequestData(method);

    if (clientRequestTime != null) {
      var latencyTime = startTime - clientRequestTime;
      requestData.latencyTimes.addValue(latencyTime);
    }

    var responseTime = sendTime.millisecondsSinceEpoch - startTime;
    requestData.responseTimes.addValue(responseTime);
  }

  /// Send information about the number of files and the number of lines of code
  /// in those files.
  Future<void> _sendAnalysisData() async {
    var contextStructure = _contextStructure;
    if (contextStructure != null) {
      analytics.send(
        Event.contextStructure(
          numberOfContexts: contextStructure.numberOfContexts,
          // TODO(pq): remove context creation data if we can safely change report shape (https://github.com/dart-lang/sdk/issues/60411)
          contextsWithoutFiles: 0,
          contextsFromPackagesFiles: 0,
          contextsFromOptionsFiles: 0,
          contextsFromBothFiles: 0,
          immediateFileCount: contextStructure.immediateFileCount,
          immediateFileLineCount: contextStructure.immediateFileLineCount,
          transitiveFileCount: contextStructure.transitiveFileCount,
          transitiveFileLineCount: contextStructure.transitiveFileLineCount,
          transitiveFileUniqueCount: contextStructure.transitiveFileUniqueCount,
          transitiveFileUniqueLineCount:
              contextStructure.transitiveFileUniqueLineCount,
        ),
      );
    }
  }

  /// Send information about the number of times each lint is enabled in an
  /// analysis options file.
  Future<void> _sendLintUsageCounts() async {
    if (_lintUsageCounts.isNotEmpty) {
      var entries = _lintUsageCounts.entries.toList();
      _lintUsageCounts.clear();
      for (var entry in entries) {
        analytics.send(
          Event.lintUsageCount(count: entry.value, name: entry.key),
        );
      }
    }
  }

  /// Send information about the notifications handled by the server.
  Future<void> _sendNotificationHandlingTimes() async {
    if (_completedNotifications.isNotEmpty) {
      var completedNotifications = _completedNotifications.values.toList();
      _completedNotifications.clear();
      for (var data in completedNotifications) {
        analytics.send(
          Event.clientNotification(
            latency: data.latencyTimes.toAnalyticsString(),
            method: data.method,
            duration: data.handlingTimes.toAnalyticsString(),
          ),
        );
      }
    }
  }

  /// Send the information that is sent periodically, which is everything other
  /// than the session data.
  Future<void> _sendPeriodicData() async {
    await _sendServerResponseTimes();
    await _sendPluginResponseTimes();
    await _sendNotificationHandlingTimes();
    await _sendLintUsageCounts();
    await _sendSeverityAdjustments();
  }

  /// Send information about the response times of plugins.
  Future<void> _sendPluginResponseTimes() async {
    var responseTimes = PluginManager.pluginResponseTimes;
    if (responseTimes.isNotEmpty) {
      var entries = responseTimes.entries.toList();
      responseTimes.clear();
      for (var pluginEntry in entries) {
        for (var responseEntry in pluginEntry.value.entries) {
          analytics.send(
            Event.pluginRequest(
              pluginId: pluginEntry.key.safePluginId,
              method: responseEntry.key,
              duration: responseEntry.value.toAnalyticsString(),
            ),
          );
        }
      }
    }
  }

  /// Send information about the response times of server.
  Future<void> _sendServerResponseTimes() async {
    if (_completedRequests.isNotEmpty) {
      var completedRequests = _completedRequests.values.toList();
      _completedRequests.clear();
      for (var data in completedRequests) {
        analytics.send(
          Event.clientRequest(
            latency: data.latencyTimes.toAnalyticsString(),
            method: data.method,
            duration: data.responseTimes.toAnalyticsString(),
            added: data.additionalPercentiles[addedKey]?.toAnalyticsString(),
            excluded:
                data.additionalPercentiles[excludedKey]?.toAnalyticsString(),
            files: data.additionalPercentiles[filesKey]?.toAnalyticsString(),
            included:
                data.additionalPercentiles[includedKey]?.toAnalyticsString(),
            openWorkspacePaths:
                data.additionalPercentiles[openWorkspacePathsKey]
                    ?.toAnalyticsString(),
            removed:
                data.additionalPercentiles[removedKey]?.toAnalyticsString(),
          ),
        );
        var commandMap = data.additionalEnumCounts[commandEnumKey];
        if (commandMap != null) {
          for (var entry in commandMap.entries) {
            analytics.send(
              Event.commandExecuted(count: entry.value, name: entry.key),
            );
          }
        }
        // TODO(brianwilkerson): We don't appear to have an event defined that we
        //  can use to send analytics about how often old-style refactorings are
        //  being invoked.
        // var refactoringMap = data.additionalEnumCounts[refactoringKindEnumKey];
      }
    }
  }

  /// Send information about the session.
  Future<void> _sendSessionData(SessionData sessionData) async {
    var endTime = DateTime.now().millisecondsSinceEpoch;
    var duration = endTime - sessionData.startTime.millisecondsSinceEpoch;
    analytics.send(
      Event.serverSession(
        flags: sessionData.commandLineArguments,
        parameters: sessionData.initializeParams,
        clientId: sessionData.clientId,
        clientVersion: sessionData.clientVersion,
        duration: duration,
      ),
    );
    for (var entry in _pluginData.usageCounts.entries) {
      analytics.send(
        Event.pluginUse(
          count: _pluginData.recordCount,
          enabled: entry.value.toAnalyticsString(),
          pluginId: entry.key,
        ),
      );
    }
  }

  /// Send information about the number of times that the severity of a
  /// diagnostic is changed in an analysis options file.
  Future<void> _sendSeverityAdjustments() async {
    if (_severityAdjustments.isNotEmpty) {
      var entries = _severityAdjustments.entries.toList();
      _severityAdjustments.clear();
      for (var entry in entries) {
        analytics.send(
          Event.severityAdjustment(
            adjustments: json.encode(entry.value),
            diagnostic: entry.key,
          ),
        );
      }
    }
  }
}

extension on Either2<int, String> {
  /// Returns a String ID for this LSP request ID.
  ///
  /// Prefixes with "LSP:" to avoid collisions with legacy IDs when both kinds
  /// of requests are being used (and may have independent/overlapping IDs).
  String get asLspIdString {
    var idString = map((value) => value.toString(), (value) => value);
    return 'LSP:$idString';
  }
}
